spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { Download } from 'lucide-react';5import { PageHeader, Section, KV, Note } from '@/components/ui/section';6import { Badge, ClaimBadge, ConfidenceBadge } from '@/components/ui/badge';7import { EmptyState } from '@/components/ui/empty-state';8import { Freshness } from '@/components/ui/freshness';9import { JsonView } from '@/components/ui/json-view';10import { BarChart } from '@/components/charts/bar-chart';11import { getMetric, snapshotsForMetric, rankingRows, type Snapshot } from '@/lib/queries/rankings';12import { sourceInfoById } from '@/lib/queries/provenance';13import { fmtDate, fmtDateTime, fmtInt, fmtValue, scopeLabel, unitLabel, humanize } from '@/lib/format';14import { str, type SP } from '@/lib/search-params';1516export const dynamic = 'force-dynamic';1718export async function generateMetadata({ params }: { params: Promise<{ metric: string }> }): Promise<Metadata> {19 const m = await getMetric((await params).metric);20 return m ? { title: `${m.name} — ranking`, description: m.description } : { title: 'Ranking' };21}2223function Delta({ rank, prev }: { rank: number; prev: number | null }) {24 if (prev == null) return <span className="text-ink-4">new</span>;25 const d = prev - rank;26 if (d === 0) return <span className="text-ink-3">=</span>;27 return (28 <span className={d > 0 ? 'text-ok' : 'text-danger'} title={`Previous rank ${prev}`}>29 {d > 0 ? '▲' : '▼'}30 {Math.abs(d)}31 </span>32 );33}3435export default async function RankingPage({ params, searchParams }: { params: Promise<{ metric: string }>; searchParams: Promise<SP> }) {36 const { metric: slug } = await params;37 const sp = await searchParams;38 const metric = await getMetric(slug);39 if (!metric) notFound();40 const snapshots = await snapshotsForMetric(slug);41 const wanted = str(sp, 'scope');42 const snap: Snapshot | null = snapshots.find((s) => s.scope_key === wanted) ?? snapshots[0] ?? null;43 const rows = snap ? await rankingRows(snap.id) : [];44 const srcInfo = snap ? await sourceInfoById(snap.source_ids) : new Map();45 const eligibility = Object.entries(metric.eligibility ?? {});46 const isBurden = metric.category === 'burden' || metric.category === 'lethality' || metric.category === 'unmet_need';4748 return (49 <div>50 <PageHeader kicker={`Ranking · ${humanize(metric.category)}`} title={metric.name} lede={metric.description}>51 <p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px] text-ink-3">52 <ClaimBadge kind="computed" />53 <span className="ci-mono">{metric.formula_version}</span>54 <span>unit: {unitLabel(metric.unit)}</span>55 <span>{metric.higher_is_worse == null ? 'higher is first (neutral)' : metric.higher_is_worse ? 'higher = worse' : 'higher = better'}</span>56 <Link href="/rankings" className="ci-link">57 All metrics58 </Link>59 </p>60 </PageHeader>6162 {snapshots.length === 0 ? (63 <EmptyState title="Ranking not yet computed" knows={[{ label: 'Metric definition and formula', href: `/methodology#${metric.slug}` }, { label: 'Source registry', href: '/sources' }, { label: 'Other rankings', href: '/rankings' }]}>64 {isBurden65 ? 'This metric depends on epidemiology observations. Global burden data (IARC / GLOBOCAN) is awaiting license review and SEER awaits credentials; no snapshot is produced until licensed observations exist for at least one scope with ten or more top-level cancers.'66 : 'This metric depends on counters that are refreshed after the relevant connector runs (ClinicalTrials.gov, PubMed, CIViC, GDC). No snapshot exists yet on this environment.'}67 <div className="mt-2">68 <span className="ci-kicker mr-2">Formula</span>69 <code className="ci-mono text-[12px]">{metric.formula}</code>70 </div>71 </EmptyState>72 ) : (73 <div className="grid gap-8 lg:grid-cols-[1fr_320px]">74 <div>75 <nav aria-label="Scope" className="mb-3 flex flex-wrap gap-1.5 text-[12.5px]">76 {snapshots.map((s) => (77 <Link key={s.id} href={`/rankings/${slug}?scope=${encodeURIComponent(s.scope_key)}`} aria-current={snap?.id === s.id ? 'page' : undefined} className={`border px-2 py-0.5 no-underline ${snap?.id === s.id ? 'border-accent bg-accent-soft text-accent-2' : 'border-rule text-ink-2 hover:border-accent'}`}>78 {scopeLabel(s.scope_key)}79 </Link>80 ))}81 </nav>82 {snap && rows.length ? (83 <>84 <BarChart ariaLabel={`Top ${Math.min(15, rows.length)} by ${metric.name}`} unit={metric.unit} data={rows.slice(0, 15).map((r) => ({ label: r.canonical_name, value: r.value, muted: r.confidence === 'LOW' || r.confidence === 'INSUFFICIENT_DATA' }))} />85 <div className="ci-table-wrap mt-4">86 <table className="ci-table">87 <thead>88 <tr>89 <th className="num">Rank</th>90 <th className="num">Δ</th>91 <th>Cancer</th>92 <th className="num">93 {metric.name} ({unitLabel(metric.unit)})94 </th>95 <th className="num">Percentile</th>96 <th>Confidence</th>97 <th>Sources</th>98 <th>Why #n?</th>99 </tr>100 </thead>101 <tbody>102 {rows.map((r) => (103 <tr key={r.id}>104 <td className="num font-medium">{r.rank}</td>105 <td className="num">106 <Delta rank={r.rank} prev={r.previous_rank} />107 </td>108 <td className="min-w-[220px]">109 <Link className="ci-link" href={`/cancer/${r.slug}/rankings`}>110 {r.canonical_name}111 </Link>112 </td>113 <td className="num">{fmtValue(r.value, r.unit)}</td>114 <td className="num text-ink-3">{r.percentile}</td>115 <td>116 <ConfidenceBadge level={r.confidence} />117 </td>118 <td className="text-[11.5px]">119 {snap.source_ids.map((id) => {120 const s = srcInfo.get(id);121 return (122 <Link key={id} href={`/source/${s?.slug ?? id}`} className="ci-mono mr-1 border border-rule px-1 text-ink-2 no-underline hover:border-accent">123 {s?.slug ?? id}124 </Link>125 );126 })}127 </td>128 <td>129 <details>130 <summary className="ci-link text-[12.5px]">inputs</summary>131 <div className="mt-1 max-w-[380px]">132 <JsonView data={r.inputs} />133 <p className="ci-mono mt-1 text-[10.5px] text-ink-4">ranking row {r.id}</p>134 </div>135 </details>136 </td>137 </tr>138 ))}139 </tbody>140 </table>141 </div>142 <Freshness dataUpdatedAt={snap.generated_at} extra={`${fmtInt(snap.eligible_entities)} eligible entities · inputs hash ${snap.inputs_hash}`} />143 </>144 ) : (145 <EmptyState compact>This snapshot has no rows.</EmptyState>146 )}147 </div>148149 <aside className="space-y-6">150 {snap ? (151 <Section id="methodology" kicker="Methodology" title="How this table was built" level={3}>152 <KV153 items={[154 { k: 'Formula', v: <code className="ci-mono text-[12px]">{metric.formula}</code> },155 { k: 'Version', v: <span className="ci-mono">{snap.formula_version}</span> },156 { k: 'Scope', v: scopeLabel(snap.scope_key) },157 { k: 'Scope key', v: <span className="ci-mono break-all text-[11.5px]">{snap.scope_key}</span> },158 { k: 'Eligible entities', v: <span className="ci-num">{fmtInt(snap.eligible_entities)}</span> },159 { k: 'Generated', v: fmtDateTime(snap.generated_at) },160 { k: 'Inputs hash', v: <span className="ci-mono break-all text-[11.5px]">{snap.inputs_hash}</span> },161 {162 k: 'Sources',163 v: snap.source_ids.length164 ? snap.source_ids.map((id) => {165 const s = srcInfo.get(id);166 return (167 <Link key={id} href={`/source/${s?.slug ?? id}`} className="ci-link mr-2">168 {s?.name ?? id}169 </Link>170 );171 })172 : '—',173 },174 { k: 'Aggregation', v: metric.aggregation ?? 'none' },175 ]}176 />177 {eligibility.length ? (178 <>179 <p className="ci-kicker mt-3">Eligibility</p>180 <JsonView data={metric.eligibility} />181 </>182 ) : null}183 <p className="mt-3 text-[12px] text-ink-3">184 <Link href={`/methodology#${metric.slug}`} className="ci-link">185 Full methodology186 </Link>{' '}187 · ties share a rank (1, 2, 2, 4) · previous rank refers to the last snapshot of the same scope.188 </p>189 <a href={`/api/export/rankings.csv?metric=${slug}&scope=${encodeURIComponent(snap.scope_key)}`} className="mt-3 inline-flex items-center gap-1.5 border border-rule px-2.5 py-1 text-[13px] text-ink no-underline hover:border-accent hover:text-accent">190 <Download className="h-3.5 w-3.5" aria-hidden /> Download CSV (with attribution)191 </a>192 </Section>193 ) : null}194 {isBurden ? <Note tone="warn">Burden and lethality metrics depend on registry definitions (site groups, standard population, estimate type). Compare only within one table.</Note> : <Note>Counts of trials, publications and evidence measure registered activity, not disease burden or treatment quality.</Note>}195 {metric.slug.includes('mortality') || metric.slug.includes('survival') ? (196 <p className="text-[12px] text-ink-3">197 <Badge tone="outline">Deadliness</Badge> annual deaths ≠ age-standardized mortality ≠ mortality-to-incidence ratio ≠ 5-year survival. Each answers a different question.198 </p>199 ) : null}200 </aside>201 </div>202 )}203 </div>204 );205}206